{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "8d7bae54-e858-410c-a574-da1aa325d90a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:23.334449Z",
     "iopub.status.busy": "2024-04-04T13:54:23.334213Z",
     "iopub.status.idle": "2024-04-04T13:54:23.427330Z",
     "shell.execute_reply": "2024-04-04T13:54:23.426462Z",
     "shell.execute_reply.started": "2024-04-04T13:54:23.334422Z"
    }
   },
   "outputs": [],
   "source": [
    "import polars as pl\n",
    "import polars_ols as pls\n",
    "import numpy as np"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "a070132d-27cd-4783-9799-f3b0a9f97ba9",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:23.434983Z",
     "iopub.status.busy": "2024-04-04T13:54:23.428344Z",
     "iopub.status.idle": "2024-04-04T13:54:23.449867Z",
     "shell.execute_reply": "2024-04-04T13:54:23.449261Z",
     "shell.execute_reply.started": "2024-04-04T13:54:23.434963Z"
    }
   },
   "outputs": [],
   "source": [
    "def _make_data(n_samples: int = 2_000, \n",
    "               n_features: int = 5,\n",
    "               n_groups: int = 5,\n",
    "               noise: float = 0.1,\n",
    "              ) -> pl.DataFrame:\n",
    "    x = np.random.normal(size=(n_samples, n_features))\n",
    "    eps = np.random.normal(size=n_samples, scale=noise)\n",
    "    return pl.DataFrame(data=x, schema=[f\"x{i + 1}\" for i in range(n_features)]).with_columns(\n",
    "        y=pl.lit(-1 * x.sum(1) + eps),\n",
    "        group=pl.lit(np.random.randint(0, n_groups, size=n_samples)),\n",
    "        sample_weights=pl.lit(np.random.rand(n_samples)),\n",
    "    )"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "5ad1869e-d884-48e2-8f37-f790057ada1a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:23.457231Z",
     "iopub.status.busy": "2024-04-04T13:54:23.456764Z",
     "iopub.status.idle": "2024-04-04T13:54:23.472340Z",
     "shell.execute_reply": "2024-04-04T13:54:23.470388Z",
     "shell.execute_reply.started": "2024-04-04T13:54:23.457199Z"
    }
   },
   "outputs": [],
   "source": [
    "df = _make_data(n_samples=2_000, n_features=3, n_groups=5)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "52c33d88-7625-40af-a2e3-892e02ef61a1",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:23.473953Z",
     "iopub.status.busy": "2024-04-04T13:54:23.473734Z",
     "iopub.status.idle": "2024-04-04T13:54:23.484063Z",
     "shell.execute_reply": "2024-04-04T13:54:23.483202Z",
     "shell.execute_reply.started": "2024-04-04T13:54:23.473931Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div><style>\n",
       ".dataframe > thead > tr,\n",
       ".dataframe > tbody > tr {\n",
       "  text-align: right;\n",
       "  white-space: pre-wrap;\n",
       "}\n",
       "</style>\n",
       "<small>shape: (2_000, 6)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>x1</th><th>x2</th><th>x3</th><th>y</th><th>group</th><th>sample_weights</th></tr><tr><td>f64</td><td>f64</td><td>f64</td><td>f64</td><td>i64</td><td>f64</td></tr></thead><tbody><tr><td>0.688224</td><td>0.166581</td><td>-1.147806</td><td>0.299763</td><td>1</td><td>0.780783</td></tr><tr><td>-0.767423</td><td>-0.172072</td><td>0.219928</td><td>0.762563</td><td>3</td><td>0.400249</td></tr><tr><td>0.007824</td><td>0.383562</td><td>0.496249</td><td>-0.85281</td><td>4</td><td>0.06215</td></tr><tr><td>-0.117801</td><td>0.324036</td><td>-0.823265</td><td>0.560717</td><td>1</td><td>0.63399</td></tr><tr><td>-0.419271</td><td>-0.030292</td><td>0.141252</td><td>0.238569</td><td>2</td><td>0.631137</td></tr><tr><td>&hellip;</td><td>&hellip;</td><td>&hellip;</td><td>&hellip;</td><td>&hellip;</td><td>&hellip;</td></tr><tr><td>1.143583</td><td>-1.119926</td><td>0.408306</td><td>-0.258559</td><td>1</td><td>0.042462</td></tr><tr><td>0.407077</td><td>-0.605242</td><td>-1.878244</td><td>2.090213</td><td>0</td><td>0.062762</td></tr><tr><td>1.49233</td><td>-1.087611</td><td>-0.648135</td><td>0.263403</td><td>0</td><td>0.697357</td></tr><tr><td>0.028772</td><td>-0.021442</td><td>-0.511965</td><td>0.43346</td><td>1</td><td>0.892621</td></tr><tr><td>-0.013978</td><td>0.029645</td><td>-1.051704</td><td>0.976311</td><td>2</td><td>0.111758</td></tr></tbody></table></div>"
      ],
      "text/plain": [
       "shape: (2_000, 6)\n",
       "┌───────────┬───────────┬───────────┬───────────┬───────┬────────────────┐\n",
       "│ x1        ┆ x2        ┆ x3        ┆ y         ┆ group ┆ sample_weights │\n",
       "│ ---       ┆ ---       ┆ ---       ┆ ---       ┆ ---   ┆ ---            │\n",
       "│ f64       ┆ f64       ┆ f64       ┆ f64       ┆ i64   ┆ f64            │\n",
       "╞═══════════╪═══════════╪═══════════╪═══════════╪═══════╪════════════════╡\n",
       "│ 0.688224  ┆ 0.166581  ┆ -1.147806 ┆ 0.299763  ┆ 1     ┆ 0.780783       │\n",
       "│ -0.767423 ┆ -0.172072 ┆ 0.219928  ┆ 0.762563  ┆ 3     ┆ 0.400249       │\n",
       "│ 0.007824  ┆ 0.383562  ┆ 0.496249  ┆ -0.85281  ┆ 4     ┆ 0.06215        │\n",
       "│ -0.117801 ┆ 0.324036  ┆ -0.823265 ┆ 0.560717  ┆ 1     ┆ 0.63399        │\n",
       "│ -0.419271 ┆ -0.030292 ┆ 0.141252  ┆ 0.238569  ┆ 2     ┆ 0.631137       │\n",
       "│ …         ┆ …         ┆ …         ┆ …         ┆ …     ┆ …              │\n",
       "│ 1.143583  ┆ -1.119926 ┆ 0.408306  ┆ -0.258559 ┆ 1     ┆ 0.042462       │\n",
       "│ 0.407077  ┆ -0.605242 ┆ -1.878244 ┆ 2.090213  ┆ 0     ┆ 0.062762       │\n",
       "│ 1.49233   ┆ -1.087611 ┆ -0.648135 ┆ 0.263403  ┆ 0     ┆ 0.697357       │\n",
       "│ 0.028772  ┆ -0.021442 ┆ -0.511965 ┆ 0.43346   ┆ 1     ┆ 0.892621       │\n",
       "│ -0.013978 ┆ 0.029645  ┆ -1.051704 ┆ 0.976311  ┆ 2     ┆ 0.111758       │\n",
       "└───────────┴───────────┴───────────┴───────────┴───────┴────────────────┘"
      ]
     },
     "execution_count": 4,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "df"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8c0702e4-ba92-4042-ab48-2044e137c52e",
   "metadata": {},
   "source": [
    "### 1. Basic Usage: OLS / WLS\n",
    "- You can use `pls.compute_least_squares` or `least_squares.ols` from the registered namespace. They are equivalent.\n",
    "- Simply pass an expression producing strictly positive sample weights to `sample_weights` argument to perform WLS"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "c7748165-bc22-4c0d-98d0-a7813d211449",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:24.124322Z",
     "iopub.status.busy": "2024-04-04T13:54:24.123758Z",
     "iopub.status.idle": "2024-04-04T13:54:24.133322Z",
     "shell.execute_reply": "2024-04-04T13:54:24.132460Z",
     "shell.execute_reply.started": "2024-04-04T13:54:24.124289Z"
    }
   },
   "outputs": [],
   "source": [
    "ols_expr = pls.compute_least_squares(pl.col(\"y\"),  # target\n",
    "                          pl.col(\"x1\"), pl.col(\"x2\"), pl.col(\"x3\"),  # features\n",
    "                          mode=\"predictions\",\n",
    "                          )\n",
    "assert str(ols_expr) == str(pl.col(\"y\").least_squares.ols(pl.col(\"x1\"), pl.col(\"x2\"), pl.col(\"x3\")))\n",
    "\n",
    "wls_expr = pl.col(\"y\").least_squares.wls(pl.col(\"x1\"), pl.col(\"x2\"), pl.col(\"x3\"), \n",
    "                                         sample_weights=pl.col(\"sample_weights\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d3026f57-54a4-453e-a803-5e7d8e943f94",
   "metadata": {},
   "source": [
    "- The expressions returned are normal polars expressions. You can operate on them lazily, so for example we can compute OLS per group in parallel using `.over(...)` or multiply it by some other expression etc."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "c824074b-cecd-43ef-a8b9-bfaeb44f77ad",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:24.569112Z",
     "iopub.status.busy": "2024-04-04T13:54:24.568388Z",
     "iopub.status.idle": "2024-04-04T13:54:24.591020Z",
     "shell.execute_reply": "2024-04-04T13:54:24.590284Z",
     "shell.execute_reply.started": "2024-04-04T13:54:24.569050Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div><style>\n",
       ".dataframe > thead > tr,\n",
       ".dataframe > tbody > tr {\n",
       "  text-align: right;\n",
       "  white-space: pre-wrap;\n",
       "}\n",
       "</style>\n",
       "<small>shape: (10, 9)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>x1</th><th>x2</th><th>x3</th><th>y</th><th>group</th><th>sample_weights</th><th>predictions_ols_group</th><th>predictions_ols</th><th>predictions_wls_masked</th></tr><tr><td>f64</td><td>f64</td><td>f64</td><td>f64</td><td>i64</td><td>f64</td><td>f32</td><td>f32</td><td>f32</td></tr></thead><tbody><tr><td>-0.239948</td><td>-0.366209</td><td>-0.561918</td><td>1.118226</td><td>1</td><td>0.63232</td><td>1.167158</td><td>1.168412</td><td>0.0</td></tr><tr><td>-1.049948</td><td>-0.947574</td><td>0.942109</td><td>1.116945</td><td>0</td><td>0.750647</td><td>1.048747</td><td>1.050843</td><td>0.0</td></tr><tr><td>-0.042678</td><td>-0.969169</td><td>-0.030938</td><td>1.171227</td><td>3</td><td>0.951124</td><td>1.038779</td><td>1.040223</td><td>0.0</td></tr><tr><td>0.27957</td><td>0.731888</td><td>1.280181</td><td>-2.252596</td><td>1</td><td>0.698999</td><td>-2.290655</td><td>-2.292642</td><td>-0.0</td></tr><tr><td>0.212038</td><td>0.002932</td><td>1.518842</td><td>-1.698552</td><td>4</td><td>0.905989</td><td>-1.729258</td><td>-1.737341</td><td>-0.0</td></tr><tr><td>1.143583</td><td>-1.119926</td><td>0.408306</td><td>-0.258559</td><td>1</td><td>0.042462</td><td>-0.429676</td><td>-0.436157</td><td>-0.0</td></tr><tr><td>0.407077</td><td>-0.605242</td><td>-1.878244</td><td>2.090213</td><td>0</td><td>0.062762</td><td>2.073736</td><td>2.079016</td><td>0.0</td></tr><tr><td>1.49233</td><td>-1.087611</td><td>-0.648135</td><td>0.263403</td><td>0</td><td>0.697357</td><td>0.242394</td><td>0.241683</td><td>0.0</td></tr><tr><td>0.028772</td><td>-0.021442</td><td>-0.511965</td><td>0.43346</td><td>1</td><td>0.892621</td><td>0.505115</td><td>0.505751</td><td>0.0</td></tr><tr><td>-0.013978</td><td>0.029645</td><td>-1.051704</td><td>0.976311</td><td>2</td><td>0.111758</td><td>1.043787</td><td>1.038543</td><td>1.038414</td></tr></tbody></table></div>"
      ],
      "text/plain": [
       "shape: (10, 9)\n",
       "┌───────────┬───────────┬───────────┬───────────┬───┬───────────┬───────────┬───────────┬──────────┐\n",
       "│ x1        ┆ x2        ┆ x3        ┆ y         ┆ … ┆ sample_we ┆ predictio ┆ predictio ┆ predicti │\n",
       "│ ---       ┆ ---       ┆ ---       ┆ ---       ┆   ┆ ights     ┆ ns_ols_gr ┆ ns_ols    ┆ ons_wls_ │\n",
       "│ f64       ┆ f64       ┆ f64       ┆ f64       ┆   ┆ ---       ┆ oup       ┆ ---       ┆ masked   │\n",
       "│           ┆           ┆           ┆           ┆   ┆ f64       ┆ ---       ┆ f32       ┆ ---      │\n",
       "│           ┆           ┆           ┆           ┆   ┆           ┆ f32       ┆           ┆ f32      │\n",
       "╞═══════════╪═══════════╪═══════════╪═══════════╪═══╪═══════════╪═══════════╪═══════════╪══════════╡\n",
       "│ -0.239948 ┆ -0.366209 ┆ -0.561918 ┆ 1.118226  ┆ … ┆ 0.63232   ┆ 1.167158  ┆ 1.168412  ┆ 0.0      │\n",
       "│ -1.049948 ┆ -0.947574 ┆ 0.942109  ┆ 1.116945  ┆ … ┆ 0.750647  ┆ 1.048747  ┆ 1.050843  ┆ 0.0      │\n",
       "│ -0.042678 ┆ -0.969169 ┆ -0.030938 ┆ 1.171227  ┆ … ┆ 0.951124  ┆ 1.038779  ┆ 1.040223  ┆ 0.0      │\n",
       "│ 0.27957   ┆ 0.731888  ┆ 1.280181  ┆ -2.252596 ┆ … ┆ 0.698999  ┆ -2.290655 ┆ -2.292642 ┆ -0.0     │\n",
       "│ 0.212038  ┆ 0.002932  ┆ 1.518842  ┆ -1.698552 ┆ … ┆ 0.905989  ┆ -1.729258 ┆ -1.737341 ┆ -0.0     │\n",
       "│ 1.143583  ┆ -1.119926 ┆ 0.408306  ┆ -0.258559 ┆ … ┆ 0.042462  ┆ -0.429676 ┆ -0.436157 ┆ -0.0     │\n",
       "│ 0.407077  ┆ -0.605242 ┆ -1.878244 ┆ 2.090213  ┆ … ┆ 0.062762  ┆ 2.073736  ┆ 2.079016  ┆ 0.0      │\n",
       "│ 1.49233   ┆ -1.087611 ┆ -0.648135 ┆ 0.263403  ┆ … ┆ 0.697357  ┆ 0.242394  ┆ 0.241683  ┆ 0.0      │\n",
       "│ 0.028772  ┆ -0.021442 ┆ -0.511965 ┆ 0.43346   ┆ … ┆ 0.892621  ┆ 0.505115  ┆ 0.505751  ┆ 0.0      │\n",
       "│ -0.013978 ┆ 0.029645  ┆ -1.051704 ┆ 0.976311  ┆ … ┆ 0.111758  ┆ 1.043787  ┆ 1.038543  ┆ 1.038414 │\n",
       "└───────────┴───────────┴───────────┴───────────┴───┴───────────┴───────────┴───────────┴──────────┘"
      ]
     },
     "execution_count": 6,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "df.lazy().with_columns(ols_expr.over(\"group\").alias(\"predictions_ols_group\"),\n",
    "                ols_expr.alias(\"predictions_ols\"),\n",
    "                (wls_expr * (pl.col(\"group\") == 2)).alias(\"predictions_wls_masked\"),\n",
    "               ).collect().tail(10)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "510b4ac4-9e35-4eda-aafe-5036f38462f8",
   "metadata": {},
   "source": [
    "- The `mode` parameter controls the type of output produced. You can choose from {`predictions`, `coefficients`, `residuals`}. It defaults to `predictions`.\n",
    "- `coefficients` produces a compact struct with the names of your features as fields and estimated coefficients as values"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "223fdff7-3242-4fdf-b732-570cd07a5b0a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:25.174668Z",
     "iopub.status.busy": "2024-04-04T13:54:25.174078Z",
     "iopub.status.idle": "2024-04-04T13:54:25.187081Z",
     "shell.execute_reply": "2024-04-04T13:54:25.186095Z",
     "shell.execute_reply.started": "2024-04-04T13:54:25.174635Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div><style>\n",
       ".dataframe > thead > tr,\n",
       ".dataframe > tbody > tr {\n",
       "  text-align: right;\n",
       "  white-space: pre-wrap;\n",
       "}\n",
       "</style>\n",
       "<small>shape: (1, 1)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>coefficients</th></tr><tr><td>struct[3]</td></tr></thead><tbody><tr><td>{-0.998207,-1.003225,-0.016527}</td></tr></tbody></table></div>"
      ],
      "text/plain": [
       "shape: (1, 1)\n",
       "┌─────────────────────────────────┐\n",
       "│ coefficients                    │\n",
       "│ ---                             │\n",
       "│ struct[3]                       │\n",
       "╞═════════════════════════════════╡\n",
       "│ {-0.998207,-1.003225,-0.016527} │\n",
       "└─────────────────────────────────┘"
      ]
     },
     "execution_count": 7,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "df.select(pl.col(\"y\").least_squares.ols(pl.col(\"x1\"), pl.col(\"x2\"), add_intercept=True, mode=\"coefficients\")\n",
    "          .alias(\"coefficients\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2025d7b3-036c-45c5-a425-088ae33bef72",
   "metadata": {},
   "source": [
    "- If in a `.over()`, `.group_by()`, or a `.with_columns()` context, the output of `mode=\"coefficients\"` broadcasts to the shape of your data\n",
    "- Computing least_squares operations in `.over()` is done in parallel in rust, so it is very efficient\n",
    "- You can use `.unnest()` to unpack the coefficients to separate numeric columns"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "4271adf2-6cb4-46ee-90c7-4e1ac1d28d93",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:25.778421Z",
     "iopub.status.busy": "2024-04-04T13:54:25.777812Z",
     "iopub.status.idle": "2024-04-04T13:54:25.787983Z",
     "shell.execute_reply": "2024-04-04T13:54:25.786979Z",
     "shell.execute_reply.started": "2024-04-04T13:54:25.778385Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "shape: (5, 2)\n",
      "┌───────┬─────────────────────────────────┐\n",
      "│ group ┆ coefficients                    │\n",
      "│ ---   ┆ ---                             │\n",
      "│ i64   ┆ struct[3]                       │\n",
      "╞═══════╪═════════════════════════════════╡\n",
      "│ 1     ┆ {-1.069746,-1.00847,-0.019304}  │\n",
      "│ 3     ┆ {-0.994216,-0.956845,-0.091147} │\n",
      "│ 4     ┆ {-0.983914,-0.991226,0.05257}   │\n",
      "│ 1     ┆ {-1.069746,-1.00847,-0.019304}  │\n",
      "│ 2     ┆ {-0.955812,-1.03732,0.021365}   │\n",
      "└───────┴─────────────────────────────────┘\n",
      "shape: (5, 4)\n",
      "┌───────┬───────────┬───────────┬───────────┐\n",
      "│ group ┆ x1        ┆ x2        ┆ const     │\n",
      "│ ---   ┆ ---       ┆ ---       ┆ ---       │\n",
      "│ i64   ┆ f32       ┆ f32       ┆ f32       │\n",
      "╞═══════╪═══════════╪═══════════╪═══════════╡\n",
      "│ 1     ┆ -1.069746 ┆ -1.00847  ┆ -0.019304 │\n",
      "│ 3     ┆ -0.994216 ┆ -0.956845 ┆ -0.091147 │\n",
      "│ 4     ┆ -0.983914 ┆ -0.991226 ┆ 0.05257   │\n",
      "│ 1     ┆ -1.069746 ┆ -1.00847  ┆ -0.019304 │\n",
      "│ 2     ┆ -0.955812 ┆ -1.03732  ┆ 0.021365  │\n",
      "└───────┴───────────┴───────────┴───────────┘\n"
     ]
    }
   ],
   "source": [
    "df_coefficients = df.select(\"group\", pl.col(\"y\").least_squares.ols(\n",
    "    pl.col(\"x1\"), pl.col(\"x2\"), add_intercept=True, mode=\"coefficients\").over(\"group\")\n",
    "          .alias(\"coefficients\"))\n",
    "print(df_coefficients.head())\n",
    "print(df_coefficients.unnest(\"coefficients\").head())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d2658468-466a-4d9e-916f-4b711331ec96",
   "metadata": {},
   "source": [
    "### 2. Regularized Models\n",
    "- Ridge `least_squares.ridge`, Lasso `least_squares.lasso`, Elastic Net `least_squares.lasso` with optional non-negative constraint are implemented\n",
    "- Apart from ridge, which is solved in closed form, the rust implementation for regularized models is cyclic coordinate descent with a soft thresholding function that supports an arbitrary combination of L1 / L2 penalties and non-negative constraint.\n",
    "- `sample_weights` and `mode` are general parameters applicable to all models supported by this package\n",
    "\n",
    "Parameters specific to regularized models are contained in `OLSKwargs`:\n",
    "- alpha: scalar representing L1 or L2 penalty strength.\n",
    "- l1_ratio: mixing parameter for ElasticNet regularization (0 for Ridge, 1 for LASSO).\n",
    "- max_iter: maximum number of coordinate descent iterations\n",
    "- tol: tolerance for convergence criterion\n",
    "- positive: boolean enforcing non-negativity constraints on coefficients"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "a9ba3765-2a42-4675-bc92-2d211bdcc03b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:26.389189Z",
     "iopub.status.busy": "2024-04-04T13:54:26.388786Z",
     "iopub.status.idle": "2024-04-04T13:54:26.442414Z",
     "shell.execute_reply": "2024-04-04T13:54:26.441781Z",
     "shell.execute_reply.started": "2024-04-04T13:54:26.389162Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/plain": [
       "\u001b[0;31mInit signature:\u001b[0m\n",
       "\u001b[0mpls\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mOLSKwargs\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\u001b[0m\n",
       "\u001b[0;34m\u001b[0m    \u001b[0malpha\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mOptional\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mfloat\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m0.0\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
       "\u001b[0;34m\u001b[0m    \u001b[0ml1_ratio\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mOptional\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mfloat\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
       "\u001b[0;34m\u001b[0m    \u001b[0mmax_iter\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mOptional\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mint\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m1000\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
       "\u001b[0;34m\u001b[0m    \u001b[0mtol\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mOptional\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mfloat\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;36m0.0001\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
       "\u001b[0;34m\u001b[0m    \u001b[0mpositive\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mOptional\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mbool\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mFalse\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\n",
       "\u001b[0;34m\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
       "\u001b[0;31mDocstring:\u001b[0m     \n",
       "Specifies parameters relevant for regularized linear models: LASSO / Ridge / ElasticNet.\n",
       "\n",
       "Attributes:\n",
       "    alpha: Regularization strength. Defaults to 0.0.\n",
       "    l1_ratio: Mixing parameter for ElasticNet regularization (0 for Ridge, 1 for LASSO).\n",
       "        Defaults to None (equivalent to Ridge regression).\n",
       "    max_iter: Maximum number of iterations. Defaults to 1000 iterations.\n",
       "    tol: Tolerance for convergence criterion. Defaults to 0.0001.\n",
       "    positive: Whether to enforce non-negativity constraints on coefficients.\n",
       "        Defaults to False (no constraint on coefficients).\n",
       "\u001b[0;31mFile:\u001b[0m           ~/projects/polars_ols/polars_ols/least_squares.py\n",
       "\u001b[0;31mType:\u001b[0m           type\n",
       "\u001b[0;31mSubclasses:\u001b[0m     "
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "# inspect OLS Kwargs\n",
    "pls.OLSKwargs?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "e35fbf57-3edb-4450-bcf7-a6c2aeef1e6f",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:26.667506Z",
     "iopub.status.busy": "2024-04-04T13:54:26.666625Z",
     "iopub.status.idle": "2024-04-04T13:54:26.683920Z",
     "shell.execute_reply": "2024-04-04T13:54:26.683218Z",
     "shell.execute_reply.started": "2024-04-04T13:54:26.667444Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div><style>\n",
       ".dataframe > thead > tr,\n",
       ".dataframe > tbody > tr {\n",
       "  text-align: right;\n",
       "  white-space: pre-wrap;\n",
       "}\n",
       "</style>\n",
       "<small>shape: (1, 2)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>coef_enet_non_negative</th><th>coef_ridge</th></tr><tr><td>struct[3]</td><td>struct[3]</td></tr></thead><tbody><tr><td>{0.0,0.0,0.0}</td><td>{-0.904563,-0.911394,-0.913853}</td></tr></tbody></table></div>"
      ],
      "text/plain": [
       "shape: (1, 2)\n",
       "┌────────────────────────┬─────────────────────────────────┐\n",
       "│ coef_enet_non_negative ┆ coef_ridge                      │\n",
       "│ ---                    ┆ ---                             │\n",
       "│ struct[3]              ┆ struct[3]                       │\n",
       "╞════════════════════════╪═════════════════════════════════╡\n",
       "│ {0.0,0.0,0.0}          ┆ {-0.904563,-0.911394,-0.913853} │\n",
       "└────────────────────────┴─────────────────────────────────┘"
      ]
     },
     "execution_count": 10,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "elastic_net_expr = pl.col(\"y\").least_squares.elastic_net(pl.col(\"x1\"), pl.col(\"x2\"), pl.col(\"x3\"),\n",
    "                                                         alpha=0.0001,\n",
    "                                                         l1_ratio=0.5,\n",
    "                                                         positive=True,\n",
    "                                                         mode=\"coefficients\",\n",
    "                                                         ).alias(\"coef_enet_non_negative\")\n",
    "\n",
    "ridge_expr = pl.col(\"y\").least_squares.ridge(pl.col(\"x1\"), pl.col(\"x2\"), pl.col(\"x3\"),\n",
    "                                             alpha=100.0, \n",
    "                                             sample_weights=pl.col(\"sample_weights\"),\n",
    "                                             mode=\"coefficients\").alias(\"coef_ridge\")\n",
    "\n",
    "df.select(elastic_net_expr, ridge_expr)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0d51c5a1-bb89-4e45-97bd-7e3bda6beae0",
   "metadata": {},
   "source": [
    "### 3. Formula API\n",
    "\n",
    "- For those who like specifying models in patsy formula syntax, that is also supported\n",
    "- You can either use the `least_squares_from_formula` module level public function or `least_squares.from_formula` from registed namespace\n",
    "- It tries to be clever and maps to the correct underlying implementation based on the model specific parameters you specify"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "8965d16a-1703-4a75-9729-d8fbadb7a1c7",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:27.290216Z",
     "iopub.status.busy": "2024-04-04T13:54:27.289873Z",
     "iopub.status.idle": "2024-04-04T13:54:27.435569Z",
     "shell.execute_reply": "2024-04-04T13:54:27.435274Z",
     "shell.execute_reply.started": "2024-04-04T13:54:27.290191Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div><style>\n",
       ".dataframe > thead > tr,\n",
       ".dataframe > tbody > tr {\n",
       "  text-align: right;\n",
       "  white-space: pre-wrap;\n",
       "}\n",
       "</style>\n",
       "<small>shape: (2, 2)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>residuals_1</th><th>residuals_2</th></tr><tr><td>f64</td><td>f64</td></tr></thead><tbody><tr><td>1.0</td><td>1.0</td></tr><tr><td>1.0</td><td>1.0</td></tr></tbody></table></div>"
      ],
      "text/plain": [
       "shape: (2, 2)\n",
       "┌─────────────┬─────────────┐\n",
       "│ residuals_1 ┆ residuals_2 │\n",
       "│ ---         ┆ ---         │\n",
       "│ f64         ┆ f64         │\n",
       "╞═════════════╪═════════════╡\n",
       "│ 1.0         ┆ 1.0         │\n",
       "│ 1.0         ┆ 1.0         │\n",
       "└─────────────┴─────────────┘"
      ]
     },
     "execution_count": 11,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# compute the residuals in two equivalent ways\n",
    "df.select(\n",
    "    # \"x2:x3\" denotes multiplicative interaction, \"-1\" dentotes no intercept\n",
    "    pls.least_squares_from_formula(\"y ~ x1 + x2:x3 -1\", mode=\"residuals\").alias(\"residuals_1\"), \n",
    "    (pl.col(\"y\") - pl.col(\"y\").least_squares.from_formula(\"x1 + x2:x3 -1\", mode=\"predictions\")).alias(\"residuals_2\"),\n",
    ").corr()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "aa610cd9-3b3f-43ce-b9cc-24a92df55307",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:27.577833Z",
     "iopub.status.busy": "2024-04-04T13:54:27.577529Z",
     "iopub.status.idle": "2024-04-04T13:54:27.582123Z",
     "shell.execute_reply": "2024-04-04T13:54:27.581714Z",
     "shell.execute_reply.started": "2024-04-04T13:54:27.577813Z"
    }
   },
   "outputs": [],
   "source": [
    "nnls_formula_expr = pl.col(\"y\").least_squares.from_formula(\"x1 + x2 + x3\",\n",
    "                                       alpha=0.0001,\n",
    "                                       positive=True,\n",
    "                                       )  # knows to use the coordinate descent implementation because of non-negativity\n",
    "\n",
    "\n",
    "ridge_formula_expr = pl.col(\"y\").least_squares.from_formula(\"x1 + x2 + x3\",\n",
    "                                       alpha=0.0001,\n",
    "                                       sample_weights=pl.col(\"sample_weights\"),\n",
    "                                       )  # knows that it needs to use closed form ridge w/ sample weighting"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ff9caf26-ae5a-46da-acd9-099bcdeee025",
   "metadata": {},
   "source": [
    "### 4. Dynamic Regression Models\n",
    "\n",
    "- Consider the situation where you want to compute coefficients in an expanding or rolling window manner\n",
    "    - naively, you could manually re-compute standard OLS function over consecutive windows (e.g. `.rolling(...).agg(...)`)\n",
    "    - ... but that would be wasteful: (X.T X) and (X.T Y) are only changing by one row (in case of expanding) or two rows (in case of rolling, an addition and a subtraction)\n",
    "- This extension package provides rust implementations `.least_squares.{rolling_ols, expanding_ols, rls}` which efficiently update coefficients as new samples are observed\n",
    "- The key idea is to make use of Sherman-Morrison or Woodbury Identity to recursively update summary statistics or coefficient vectors\n",
    "- Formula API is also supported and the correct implementation is chosen based on parameters provided"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "33a8ee68-dc8b-4a2e-a590-73a4998bc33e",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:54:28.355492Z",
     "iopub.status.busy": "2024-04-04T13:54:28.354941Z",
     "iopub.status.idle": "2024-04-04T13:54:28.381642Z",
     "shell.execute_reply": "2024-04-04T13:54:28.381188Z",
     "shell.execute_reply.started": "2024-04-04T13:54:28.355460Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div><style>\n",
       ".dataframe > thead > tr,\n",
       ".dataframe > tbody > tr {\n",
       "  text-align: right;\n",
       "  white-space: pre-wrap;\n",
       "}\n",
       "</style>\n",
       "<small>shape: (2_000, 3)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>rolling_ridge_coef</th><th>recursive_least_squares_coef</th><th>expanding_ols_pred</th></tr><tr><td>struct[3]</td><td>struct[3]</td><td>f32</td></tr></thead><tbody><tr><td>{0.0,0.0,0.0}</td><td>{-0.997571,-0.999412,-1.004052}</td><td>0.284141</td></tr><tr><td>{0.0,0.0,0.0}</td><td>{-1.043208,-1.009688,-0.987618}</td><td>0.553695</td></tr><tr><td>{0.0,0.0,0.0}</td><td>{-0.999444,-0.97275,-0.964744}</td><td>-0.711872</td></tr><tr><td>{0.0,0.0,0.0}</td><td>{-0.938403,-1.031698,-0.967921}</td><td>0.564383</td></tr><tr><td>{0.0,0.0,0.0}</td><td>{-0.900343,-0.9928,-1.033574}</td><td>0.263497</td></tr><tr><td>&hellip;</td><td>&hellip;</td><td>&hellip;</td></tr><tr><td>{-1.00591,-1.000234,-1.002072}</td><td>{-1.004769,-1.006106,-0.981976}</td><td>-0.436183</td></tr><tr><td>{-0.995429,-0.995031,-0.995304}</td><td>{-0.999747,-0.994992,-0.982279}</td><td>2.078985</td></tr><tr><td>{-0.995239,-0.995055,-0.995537}</td><td>{-0.997495,-0.99649,-0.982933}</td><td>0.241709</td></tr><tr><td>{-1.005733,-0.999528,-1.001679}</td><td>{-1.005132,-1.005992,-0.980991}</td><td>0.505744</td></tr><tr><td>{-1.006673,-0.995687,-1.013054}</td><td>{-1.015187,-0.9873,-1.038931}</td><td>1.038495</td></tr></tbody></table></div>"
      ],
      "text/plain": [
       "shape: (2_000, 3)\n",
       "┌─────────────────────────────────┬─────────────────────────────────┬────────────────────┐\n",
       "│ rolling_ridge_coef              ┆ recursive_least_squares_coef    ┆ expanding_ols_pred │\n",
       "│ ---                             ┆ ---                             ┆ ---                │\n",
       "│ struct[3]                       ┆ struct[3]                       ┆ f32                │\n",
       "╞═════════════════════════════════╪═════════════════════════════════╪════════════════════╡\n",
       "│ {0.0,0.0,0.0}                   ┆ {-0.997571,-0.999412,-1.004052} ┆ 0.284141           │\n",
       "│ {0.0,0.0,0.0}                   ┆ {-1.043208,-1.009688,-0.987618} ┆ 0.553695           │\n",
       "│ {0.0,0.0,0.0}                   ┆ {-0.999444,-0.97275,-0.964744}  ┆ -0.711872          │\n",
       "│ {0.0,0.0,0.0}                   ┆ {-0.938403,-1.031698,-0.967921} ┆ 0.564383           │\n",
       "│ {0.0,0.0,0.0}                   ┆ {-0.900343,-0.9928,-1.033574}   ┆ 0.263497           │\n",
       "│ …                               ┆ …                               ┆ …                  │\n",
       "│ {-1.00591,-1.000234,-1.002072}  ┆ {-1.004769,-1.006106,-0.981976} ┆ -0.436183          │\n",
       "│ {-0.995429,-0.995031,-0.995304} ┆ {-0.999747,-0.994992,-0.982279} ┆ 2.078985           │\n",
       "│ {-0.995239,-0.995055,-0.995537} ┆ {-0.997495,-0.99649,-0.982933}  ┆ 0.241709           │\n",
       "│ {-1.005733,-0.999528,-1.001679} ┆ {-1.005132,-1.005992,-0.980991} ┆ 0.505744           │\n",
       "│ {-1.006673,-0.995687,-1.013054} ┆ {-1.015187,-0.9873,-1.038931}   ┆ 1.038495           │\n",
       "└─────────────────────────────────┴─────────────────────────────────┴────────────────────┘"
      ]
     },
     "execution_count": 13,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "df.select(\n",
    "    pl.col(\"y\").least_squares.from_formula(\"x1 + x2 + x3 -1\", \n",
    "                                           window_size=252, \n",
    "                                           min_periods=5, \n",
    "                                           alpha=0.0001,  \n",
    "                                           mode=\"coefficients\").over(\"group\").alias(\"rolling_ridge_coef\"),\n",
    "    pl.col(\"y\").least_squares.rls(\n",
    "        pl.col(\"x1\"), pl.col(\"x2\"), pl.col(\"x3\"),\n",
    "        half_life=21.0, # exponential memory proportional to a half-life of 21 samples\n",
    "        initial_state_mean=[-1.0, -1.0, -1.0],  # prior mean for initial coefficients\n",
    "        initial_state_covariance=10.0,  # inversely proportional to L2 prior towards prior mean\n",
    "        mode=\"coefficients\",\n",
    "    ).over(\"group\").alias(\"recursive_least_squares_coef\"),\n",
    "    pl.col(\"y\").least_squares.expanding_ols(pl.col(\"x1\"), pl.col(\"x2\"), pl.col(\"x3\"), \n",
    "                                           mode=\"predictions\").alias(\"expanding_ols_pred\"),\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "30fe069d-8f4f-47e7-9f7b-cac7ef750823",
   "metadata": {},
   "source": [
    "### 5. Out Of Sample Prediction\n",
    "\n",
    "- If you want to fit on some data then predict on test data, you can do so with `least_squares.predict(...)`"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 21,
   "id": "ff412921-2c2d-46b3-baad-d54067fa3312",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:55:16.975549Z",
     "iopub.status.busy": "2024-04-04T13:55:16.974995Z",
     "iopub.status.idle": "2024-04-04T13:55:16.992064Z",
     "shell.execute_reply": "2024-04-04T13:55:16.990648Z",
     "shell.execute_reply.started": "2024-04-04T13:55:16.975516Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div><style>\n",
       ".dataframe > thead > tr,\n",
       ".dataframe > tbody > tr {\n",
       "  text-align: right;\n",
       "  white-space: pre-wrap;\n",
       "}\n",
       "</style>\n",
       "<small>shape: (5, 2)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>group</th><th>coefficients</th></tr><tr><td>i64</td><td>struct[2]</td></tr></thead><tbody><tr><td>1</td><td>{-1.068436,-1.009836}</td></tr><tr><td>0</td><td>{-0.979399,-1.027145}</td></tr><tr><td>4</td><td>{-0.983794,-0.991228}</td></tr><tr><td>3</td><td>{-0.996063,-0.960127}</td></tr><tr><td>2</td><td>{-0.954715,-1.03776}</td></tr></tbody></table></div>"
      ],
      "text/plain": [
       "shape: (5, 2)\n",
       "┌───────┬───────────────────────┐\n",
       "│ group ┆ coefficients          │\n",
       "│ ---   ┆ ---                   │\n",
       "│ i64   ┆ struct[2]             │\n",
       "╞═══════╪═══════════════════════╡\n",
       "│ 1     ┆ {-1.068436,-1.009836} │\n",
       "│ 0     ┆ {-0.979399,-1.027145} │\n",
       "│ 4     ┆ {-0.983794,-0.991228} │\n",
       "│ 3     ┆ {-0.996063,-0.960127} │\n",
       "│ 2     ┆ {-0.954715,-1.03776}  │\n",
       "└───────┴───────────────────────┘"
      ]
     },
     "execution_count": 21,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# make some random training data\n",
    "df_train = _make_data(n_groups=1)\n",
    "\n",
    "# fit coefficients\n",
    "df_coefficients = (\n",
    "    df.lazy()\n",
    "    .select(\n",
    "        \"group\",\n",
    "        pl.col(\"y\")\n",
    "        .least_squares.ols(pl.col(\"x1\"), pl.col(\"x2\"), mode=\"coefficients\")\n",
    "        .over(\"group\").alias(\"coefficients\"),\n",
    "    )\n",
    "    .unique()\n",
    ")\n",
    "\n",
    "df_coefficients.collect()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 22,
   "id": "931b667e-cf4d-4b53-be9b-85278c6fd16c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2024-04-04T13:55:17.194466Z",
     "iopub.status.busy": "2024-04-04T13:55:17.193582Z",
     "iopub.status.idle": "2024-04-04T13:55:17.210217Z",
     "shell.execute_reply": "2024-04-04T13:55:17.209573Z",
     "shell.execute_reply.started": "2024-04-04T13:55:17.194402Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div><style>\n",
       ".dataframe > thead > tr,\n",
       ".dataframe > tbody > tr {\n",
       "  text-align: right;\n",
       "  white-space: pre-wrap;\n",
       "}\n",
       "</style>\n",
       "<small>shape: (5, 2)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>group</th><th>predictions_test</th></tr><tr><td>i64</td><td>f32</td></tr></thead><tbody><tr><td>0</td><td>-2.424038</td></tr><tr><td>0</td><td>0.177278</td></tr><tr><td>0</td><td>-2.637851</td></tr><tr><td>0</td><td>-0.169265</td></tr><tr><td>0</td><td>0.262978</td></tr></tbody></table></div>"
      ],
      "text/plain": [
       "shape: (5, 2)\n",
       "┌───────┬──────────────────┐\n",
       "│ group ┆ predictions_test │\n",
       "│ ---   ┆ ---              │\n",
       "│ i64   ┆ f32              │\n",
       "╞═══════╪══════════════════╡\n",
       "│ 0     ┆ -2.424038        │\n",
       "│ 0     ┆ 0.177278         │\n",
       "│ 0     ┆ -2.637851        │\n",
       "│ 0     ┆ -0.169265        │\n",
       "│ 0     ┆ 0.262978         │\n",
       "└───────┴──────────────────┘"
      ]
     },
     "execution_count": 22,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# make some test data\n",
    "df_test = _make_data(n_groups=1)\n",
    "\n",
    "# 1) join on group or common index columns etc.\n",
    "# 2) compute predictions by calling least_squares.predict(coefficient_column, *feature_columns)\n",
    "predictions = (\n",
    "    df_test.lazy()\n",
    "    .join(df_coefficients, on=\"group\")\n",
    "    .select(\n",
    "        \"group\",\n",
    "        pl.col(\"coefficients\").least_squares.predict(\n",
    "            pl.col(\"x1\"), pl.col(\"x2\"), name=\"predictions_test\"\n",
    "        )\n",
    "    )\n",
    "    .collect()\n",
    ")\n",
    "\n",
    "predictions.head()"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.10.11"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
